[SEP-2575][SEP-2567] 2026-06 stateless support: stdio + InMemory transports#2132
[SEP-2575][SEP-2567] 2026-06 stateless support: stdio + InMemory transports#2132felixweinberger wants to merge 3 commits into
Conversation
🦋 Changeset detectedLatest commit: 92f410a The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
dd9e762 to
af76de4
Compare
7995ab3 to
32ef72b
Compare
|
@claude review |
af76de4 to
b837b6c
Compare
32ef72b to
0eb606f
Compare
NEW core/shared/streamDriver.ts: minimal request->response correlator for pipe-shaped client transports (one _pending map keyed by RequestId AND String(id) for _meta.subscriptionId routing). sendAndReceive yields notifications then one response (or indefinite for subscriptions/listen); break/return() sends notifications/cancelled. Send-failure -> synthetic error response (no hang). onMessage routes; close() ends all pending. InMemoryTransport: composes StreamDriver. send() routes via _receive() (driver claims first, falls through to onmessage). close() closes driver. StdioClientTransport: composes StreamDriver. processReadBuffer routes to driver when protocolVersion is unset or stateless. setProtocolVersion() gates back to onmessage for legacy. Satisfies: 2575-R12 (client sendAndReceive contract, pipe transports)
…outers
NEW core/shared/serverStatelessRouter.ts: routeServerStateless() —
per-message branch on isStatelessRequest to StatelessHandlers
{dispatch,listen}; notifications/cancelled aborts matching _inflight
controller.
stdio + InMemory server-side: receive paths route via
routeServerStateless; close() aborts all in-flight.
stdio + InMemory client-side: sendAndReceive threads opts?.signal to
StreamDriver.
Satisfies: 2567-R1 (pipe), 2567-R2
Migrate existing connection-model tests to LegacyTestClient (client.test, server.test, mcp.test, elicitation.test, stateManagementStreamableHttp, taskResumability) — they exercise server-to-client RPCs / oninitialized which require the legacy initialize path. statelessAcceptance.test.ts: add 'Client over InMemory' describe block (auto-probe, legacy negotiate, subscribe demux, MRTR auto-resume). NEW zeroChangeConsumer.test.ts: InMemory describe.each over Server/McpServer ctors + MRTR hardening (accumulate, max-rounds, requestSampling).
b837b6c to
ddfc2b3
Compare
0eb606f to
92f410a
Compare
|
@claude review |
| import supertest from 'supertest'; | ||
| import * as z from 'zod/v4'; | ||
|
|
||
| import { LegacyTestClient } from './__fixtures__/testClient.js'; |
There was a problem hiding this comment.
🟡 After every new Client(...) here was migrated to new LegacyTestClient(...), the Client import on line 2 is now dead — but the file-level /* eslint-disable @typescript-eslint/no-unused-vars */ on line 1 silently masks the unused-import warning. Drop Client from the import (as this PR already did in client/client.test.ts, and via import type in elicitation.test.ts/mcp.test.ts).
Extended reasoning...
What the bug is. This PR migrates all 189 new Client(...) call sites in test/integration/test/server.test.ts to new LegacyTestClient(...) and adds import { LegacyTestClient } from './__fixtures__/testClient.js' on line 26. However, the original import { Client } from '@modelcontextprotocol/client' on line 2 was left behind and is now completely unreferenced.
The code path. Grepping for the bare identifier Client in the post-PR file shows the only place it appears as code is the import on line 2. Every other occurrence is inside a string literal (test assertion expectations such as 'Client does not support...') or a comment. There is no remaining new Client(, no : Client type annotation, and no typeof Client reference.
Why nothing catches it. The file already begins with /* eslint-disable @typescript-eslint/no-unused-vars */ (line 1), which was presumably added at some point for unused destructured args in test helper signatures. Because that disable is file-scoped and the rule covers unused imports as well, ESLint never flags the now-dead Client import, so CI passes silently.
Why this is inconsistent within the PR. The same PR handled the identical migration in three sibling files differently — and correctly:
test/integration/test/client/client.test.tschangedimport { Client, getSupportedElicitationModes }→import { getSupportedElicitationModes }, droppingClientonce unused.test/integration/test/server/elicitation.test.tsandtest/integration/test/server/mcp.test.tsconvertedimport { Client }→import type { Client }because they still useClientas a type annotation.
server.test.ts is the one file where the cleanup was missed, and it happens to be the one file with the lint suppression that hides the leftover.
Step-by-step proof.
- Pre-PR,
server.test.tshasimport { Client } from '@modelcontextprotocol/client'and ~70new Client(...)call sites. - The PR adds
import { LegacyTestClient } from './__fixtures__/testClient.js'and replaces everynew Client(withnew LegacyTestClient(. - After the rewrite, search the file for the bare token
Client(excludingLegacyTestClient). Hits: line 2 (the import), and string literals like'/^Client does not support/'in.rejects.toThrow(...)matchers. - No remaining hit is a value or type reference, so the import is dead.
@typescript-eslint/no-unused-varswould flag an unused import — but line 1 disables that rule for the whole file, so the build is green.
Impact. None at runtime — this is test code and unused imports don't change behavior. The cost is hygiene: a stale import that suggests Client is still in play, plus an inconsistency with the sibling files that this same PR cleaned up correctly.
Fix. Delete Client from the line-2 import. If nothing else from @modelcontextprotocol/client is used (it isn't), delete the import line entirely. Optionally, while there, audit whether the file-level eslint-disable @typescript-eslint/no-unused-vars is still necessary — it's the reason this slipped through.
| function listenErrorResponse(id: RequestId, error: unknown) { | ||
| if (error instanceof ProtocolError) { | ||
| return errorResponse(id, error.code, error.message); | ||
| } | ||
| if (error instanceof ZodError) { | ||
| return errorResponse(id, ProtocolErrorCode.InvalidParams, error.message); | ||
| } | ||
| return errorResponse(id, ProtocolErrorCode.InternalError, error instanceof Error ? error.message : 'Subscription failed'); | ||
| } |
There was a problem hiding this comment.
🟡 listenErrorResponse() drops the public error.data field when handling a ProtocolError, so structured data (e.g. requiredCapabilities on a MissingRequiredClientCapability error from a custom SubscriptionBackend) is lost over pipe transports while the dispatcher's own catch (dispatcher.ts:184-189) preserves it. Fix is one line: pass error.data as the 4th errorResponse() arg. Also note: the sibling HTTP listen catch in statelessHttp.ts:153-160 (flagged in the prior review and replied to with "Fixed in 20488dd") was not actually updated — it still maps every throw to InvalidParams/400 and should mirror this discrimination.
Extended reasoning...
1. listenErrorResponse() drops ProtocolError.data
ProtocolError carries a public readonly data?: unknown field (packages/core/src/types/errors.ts:12), and errorResponse() (packages/core/src/shared/dispatcher.ts:200) accepts a 4th data?: unknown parameter that it spreads into the wire error. The dispatcher's own catch block (dispatcher.ts:184-189) propagates e.data into the JSON-RPC error. But the new listenErrorResponse() here calls:
if (error instanceof ProtocolError) {
return errorResponse(id, error.code, error.message); // ← drops error.data
}Concrete walkthrough. A custom SubscriptionBackend.handle() (the docs explicitly say to supply a distributed implementation for horizontal scale) throws:
throw new ProtocolError(
ProtocolErrorCode.MissingRequiredClientCapability,
'Client must declare resources.subscribe',
{ requiredCapabilities: { resources: { subscribe: true } } }
);- Over HTTP (the dispatch path), the dispatcher catch forwards
e.dataand the client seeserror.data.requiredCapabilities. - Over stdio/InMemory (this PR),
handleOne()→listenErrorResponse()buildserrorResponse(id, code, message)and the client receiveserror.data === undefined. The structured data is silently gone, and a client that branches onrequiredCapabilitiesto fix up its capabilities and retry has nothing to go on.
This is an asymmetry purely between transports for the same throw, which is exactly what serverStatelessRouter is supposed to avoid.
Fix:
if (error instanceof ProtocolError) {
return errorResponse(id, error.code, error.message, error.data);
}Practical impact is small (only custom backends that throw ProtocolError with structured data from handle() are affected; the bundled InMemorySubscriptions doesn't), so this alone is a nit.
2. The sibling HTTP listen catch was left unfixed
The earlier review on this PR flagged that the listen catch hard-coded InvalidParams for every throw and noted: "The same shape exists in statelessHttp.ts:153-159 from #2131, so both sites are worth fixing together." The reply was "Fixed in 20488dd. The listen catch now discriminates ZodError/ProtocolError from other throws…" — but 20488ddb only touched serverStatelessRouter.ts. packages/server/src/server/statelessHttp.ts:153-160 (current HEAD) still reads:
} catch (error) {
return jsonError(
400,
ProtocolErrorCode.InvalidParams,
error instanceof Error ? error.message : 'Invalid listen request',
listen.r.id
);
}Server-internal failures from a custom SubscriptionBackend.handle() — broker connection loss, DB lookup failure — still surface to HTTP clients as -32602 InvalidParams / HTTP 400, signalling a client fault that isn't one. statelessHttp.ts is not in this PR's diff so on a strict reading the remaining bug is pre-existing, but since the PR establishes the discrimination pattern (listenErrorResponse) and the author indicated both sites were addressed, it's worth noting the migration is partial. The HTTP path should mirror listenErrorResponse(): ZodError/ProtocolError(InvalidParams) → 400, everything else → 500 InternalError.
| const response = await handlers.dispatch(req, { signal: ac.signal, authInfo: ctx.authInfo, notify: write }); | ||
| write(response); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 The non-listen else branch of handleOne() awaits handlers.dispatch() with no try/catch — if it rejects, the rejection only reaches routeServerStateless()'s .catch(onerror) and nothing is written to the pipe, so the client's StreamDriver iterator hangs until the 60s default timeout. This is the same shape just fixed for the listen branch in 20488dd (and defensively guarded in the SSE path via controller.error() in statelessHttp.ts); wrap the await in try/catch and write errorResponse(req.id, ProtocolErrorCode.InternalError, ...) on rejection.
Extended reasoning...
The gap
handleOne()'s non-listen branch (serverStatelessRouter.ts:81-83) is:
} else {
const response = await handlers.dispatch(req, { signal: ac.signal, authInfo: ctx.authInfo, notify: write });
write(response);
}There is no try/catch. If handlers.dispatch() rejects, the rejection propagates to routeServerStateless()'s .catch(error => onerror?.(...)) (line 28) which only logs. Nothing is written to the pipe. The client's StreamDriver registered a queue under req.id; for a non-listen request the wrapper in streamDriver.ts only ends after a JSONRPCResponse for that id arrives, or on return()/close()/onAbort. With no message ever keyed to req.id, the client's for await blocks until the request-level AbortSignal fires — DEFAULT_REQUEST_TIMEOUT_MSEC (60 s) by default.
Why the sibling paths don't have this gap
- The listen branch in this same function was hardened in 20488dd to write a terminal
errorResponse(req.id, …)whenever the for-await throws or ends without a client abort, specifically soStreamDriver's queue closes instead of hanging. The else branch is the same failure shape, unguarded. - The HTTP path (
statelessHttp.ts):handleHttp's outer try/catch turns a rejected dispatch into a 500 JSON-RPC error response, and the SSE branch attaches.catch(error => controller.error(error))to the dispatch promise so the response stream errors and the client's reader observes EOF. Over stdio/InMemory there is no per-request connection — the pipe stays open and the only signal the client can observe is a JSON-RPC message keyed toreq.id.
Trigger likelihood (why this is a nit, not a blocker)
For the bundled handlers (Server.statelessHandlers().dispatch → _dispatchStateless), a rejection is hard to reach: dispatcher.dispatch() has its own internal try/catch and always returns an errorResponse on throw, and parseClientMeta/_buildDispatchServerContext are sync, defensive type checks. The realistic triggers are (a) the post-processing in _dispatchStateless (e.g., the resultType spread on response.result) throwing if a handler returns a non-object value that bypasses TypeScript, or (b) a user-supplied StatelessHandlers installed via the public setStatelessHandlers() whose dispatch rejects. Neither is common — but StatelessHandlers is an exported, public extension point, and the failure mode when it does happen (60 s of silence followed by an opaque timeout error) is much harder to debug than an immediate InternalError response.
Step-by-step reproduction
- A user installs a custom
StatelessHandlerson aStdioServerTransportviatransport.setStatelessHandlers({ dispatch: async () => { throw new Error('db down') }, listen: ... }). - A 2026-06 client calls
client.callTool({ name: 'echo' })over stdio.StreamDriver.sendAndReceive()registers a queue under id0x40000000and writes the request. - The server's
processReadBuffercallsrouteServerStateless→handleOne→handlers.dispatch(req, …), which rejects. - The rejection propagates out of
handleOne, is caught by.catch(error => onerror?.(...)), and is logged.inflightis cleaned up by.finally(). No write to the pipe. - The client's
StreamDriverqueue for id0x40000000never receives a message.callTool()blocks oninner.next()for 60 seconds, then the requestAbortSignalfires and the iterator is cancelled, surfacing a timeout error to the application — not the actual'db down'failure.
Fix
Mirror the listen-branch fix:
} else {
try {
const response = await handlers.dispatch(req, { signal: ac.signal, authInfo: ctx.authInfo, notify: write });
write(response);
} catch (error) {
if (!ac.signal.aborted) {
write(errorResponse(req.id, ProtocolErrorCode.InternalError, error instanceof Error ? error.message : 'Dispatch failed'));
}
throw error;
}
}This keeps the rejection propagating to onerror for logging while ensuring the client always observes a terminal JSON-RPC message keyed to req.id, matching the SSE path's controller.error() and the listen branch's terminal write.
Extends 2026-06 stateless support to stdio + InMemory transports. Additive:
StreamDriver(client-side correlation for pipe transports),serverStatelessRouter(server-side per-message router),sendAndReceiveimpls. Migrates 189 connection-model tests toLegacyTestClient.Motivation and Context
Same SEPs over non-HTTP transports.
How Has This Been Tested?
pnpm test:all(1367). InMemory acceptance scenarios +zeroChangeConsumer(same handler under both protocols).Breaking Changes
None.
Types of changes
Checklist